今日目標
了解 DOM 與常用的節點操作:查找、內容、屬性、樣式、類別、資料屬性
會用 事件綁定(click、input)及 事件委派(event delegation)
在履歷網站加入三個互動功能:主題切換、技能分類篩選、照片切換
基礎概念
什麼是 DOM?
DOM(Document Object Model)把 HTML 文件轉成「節點(Node)樹」,你可以用 JavaScript/TypeScript 操作它:查找元素、改文字、改屬性、改樣式、監聽事件等等。
常用 API 速查(本篇會全部用到)
查找元素:querySelector、querySelectorAll
內容:textContent、innerHTML(本篇盡量用 textContent)
屬性:getAttribute、setAttribute、hasAttribute、removeAttribute
樣式與類別:classList.add/remove/toggle、style.xxx
自訂資料屬性:dataset(如 data-category)
事件:addEventListener('click', handler)、preventDefault()、stopPropagation()
事件委派:監聽父節點,靠 event.target 判斷實際點擊者
實作一:主題切換(亮/暗)
HTML(在 裡放一顆切換按鈕)
切換主題
建議 Day 2 的 CSS/SCSS 有用到顏色變數(或 root 變數),這裡只示範最小掛鉤:當 有 data-theme="dark" 就套用深色主題。
最小 CSS 掛鉤(節錄,供參考)
/* 你可以在 Day 2 的樣式檔中加入 */
:root {
--bg: #ffffff;
--fg: #2c3e50;
}
html[data-theme="dark"] {
--bg: #1f2937;
--fg: #f3f4f6;
}
body { background: var(--bg); color: var(--fg); }
TypeScript(在 main.ts)
// 主題切換:把狀態存在 與 localStorage
const htmlEl = document.documentElement;
const themeToggleBtn = document.querySelector('#theme-toggle');
function applyTheme(theme: 'light' | 'dark') {
htmlEl.setAttribute('data-theme', theme);
if (themeToggleBtn) {
themeToggleBtn.setAttribute('aria-pressed', String(theme === 'dark'));
themeToggleBtn.textContent = theme === 'dark' ? '切換為亮色' : '切換為暗色';
}
localStorage.setItem('theme', theme);
}
// 初始化:讀取上次選擇
const saved = (localStorage.getItem('theme') as 'light' | 'dark') || 'light';
applyTheme(saved);
// 綁定按鈕
themeToggleBtn?.addEventListener('click', () => {
const next = htmlEl.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
applyTheme(next as 'light' | 'dark');
});
實作二:技能分類篩選(全部/前端/後端/工具)
HTML(在 Skills 區塊增加篩選器與標籤上的 data-category)
TypeScript(事件委派處理整個篩選器)
type SkillCategory = 'all' | 'frontend' | 'backend' | 'tools';
const filters = document.querySelector('#skill-filters');
const skillList = document.querySelector('#skill-list');
function applySkillFilter(cat: SkillCategory) {
if (!skillList) return;
const items = Array.from(skillList.querySelectorAll('li'));
items.forEach(li => {
const c = (li.dataset.category || 'frontend') as SkillCategory;
li.style.display = (cat === 'all' || c === cat) ? '' : 'none';
});
}
filters?.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.matches('button[data-filter]')) {
const cat = target.getAttribute('data-filter') as SkillCategory;
// 視覺/a11y 狀態
filters.querySelectorAll('[aria-selected="true"]').forEach(el => el.setAttribute('aria-selected', 'false'));
target.setAttribute('aria-selected', 'true');
// 套用篩選
applySkillFilter(cat);
}
});
// 預設顯示全部
applySkillFilter('all');
實作三:照片切換(正式照/生活照)
HTML(在 About 區塊加入兩張圖的來源路徑)
這裡使用 data-alt-src 存放替代圖片路徑,日後在框架中也很好綁定。
TypeScript
const img = document.querySelector('#avatar');
const photoToggle = document.querySelector('#photo-toggle');
photoToggle?.addEventListener('click', () => {
if (!img) return;
const current = img.getAttribute('src') || '';
const altSrc = img.dataset.altSrc || '';
if (!altSrc) return;
// 交換 src 與 data-alt-src
img.setAttribute('src', altSrc);
img.dataset.altSrc = current;
// 同步替代文字(若兩張照性質不同)
const isFormal = /formal/.test(altSrc);
img.alt = isFormal ? 'Chiayu 的正式照片' : 'Chiayu 的生活照片';
});
成果
完成以上三段後,你的履歷網站具備:
主題切換:可記住使用者偏好(localStorage),重載仍生效。
技能篩選:以 data-category 為依據,不用改 HTML 結構就能擴充。
照片切換:一鍵切換兩張照片,實作了自訂資料屬性與屬性交換。
小心踩雷(常見誤用 → 正確作法)
直接用 innerHTML 填入未消毒字串
錯誤:
titleEl.innerHTML = userInput; // 可能造成 XSS
正確:
titleEl.textContent = userInput; // 文字內容請用 textContent
忘了考慮元素可能為 null
錯誤:
document.querySelector('#x')!.addEventListener('click', fn);
正確:
const el = document.querySelector('#x');
if (el) el.addEventListener('click', fn);
為每一個子元素都綁監聽,造成效能浪費
錯誤:
document.querySelectorAll('#skill-filters button')
.forEach(b => b.addEventListener('click', onClick));
正確:事件委派(監聽父元素)
filters?.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.matches('button[data-filter]')) { /* ... */ }
});
用行內樣式硬改所有外觀,導致樣式與行為耦合
錯誤:
el.style.display = 'none';
el.style.color = 'red';
正確:
el.classList.add('is-hidden'); // 把樣式定義在 CSS 中
濫用 dataset 當狀態倉庫
錯誤:
el.dataset.state = JSON.stringify({ aLotOfState: true }); // 難以維護
正確: dataset 存放輕量、與 DOM 強相關的設定值(如類別、替代路徑);複雜狀態請放 JS/TS 內部結構。
進一步練習(可選)
技能清單支援「關鍵字即時搜尋」(監聽 input 事件 → includes 過濾)
主題切換加上動畫過渡(CSS transition)
按下導覽列的錨點時,平滑滾動到目標區塊(scrollIntoView({ behavior: 'smooth' }))
下一步(Day 5 預告)
明天我們會做一個 原生 HTML/CSS/TS 的一頁式自我介紹頁(小專案),把 Day1–Day4 的知識整合起來,形成「無框架也能交付」的最小可用作品。
接著 Day 6 起,我們會把同樣的資訊架構搬進 Angular,正式開始框架實戰。